fix(hooks): a linked worktree is one whose git-dir differs from its git-common-dir - #7749
Conversation
…it-common-dir Both worktree-first guards decided "am I in a linked worktree?" by substring-matching the git-dir path against `*/worktrees/*`. That is a test for the characters `worktrees` appearing anywhere in a path, not a test for a linked worktree: a PRIMARY checkout that merely lives under a directory named `worktrees` matched it, and both guards allowed edits into a shared primary checkout — the exact failure worktree-first exists to stop (see #7259). The verdict was also depth-dependent, which is what made it reachable in practice. `git rev-parse --git-dir` prints a RELATIVE `.git` at a repo toplevel and an ABSOLUTE path from any subdirectory, and the guards hand git the edited file's nearest EXISTING ancestor. So the same unguarded checkout blocked for a path resolving to the toplevel and allowed for anything resolving to a subdirectory — and in a real repo almost every edit is to a file in a subdirectory that already exists. Replaced with the structural test: a linked worktree's git-dir (.git/worktrees/NAME) differs from its git-common-dir (.git); a primary checkout has the two equal, and so does a submodule (.git/modules/NAME for both), so neither needs a special case. Two details are load-bearing and both are measured, not assumed: * `--git-common-dir` prints RELATIVE to the directory queried (`.git` at a toplevel, `../.git` from a subdirectory), so it must be resolved against that directory before the comparison. Compared raw it never equals the absolute git-dir and the guard fails open at EVERY depth — ablating just that line turns 47 matrix cases from block to allow. * `--absolute-git-dir` alone is NOT a fix. It removes the toplevel/subdirectory asymmetry by making the guard fail open everywhere instead of somewhere. Both sides are canonicalised through one helper so symlinked temp dirs and git's relative printing cannot make two spellings of the same directory look different. Self-tests: the four `worktrees`-segment cases are re-homed out of the KNOWN HOLE section and all four now expect `block`; the Bash matrix gains that fixture and five cases it never had. The non-vacuity recipe is re-aimed at the line that now exists, so it is not a dead mutation. The executable lines stay byte-identical to the sibling repo's copies of both hooks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
|
ACCEPT — lands #7259 as ruled, one flight with objectstack PR #15924 (#11809). Governed ( What the seat verified, at head
Reconciliation: the 13:59Z Implemented-by: os-dev executor, flight objectui #7259 + objectstack #11809, branches Generated by Claude Code |
维护者速读改了什么:两个「禁止改共享主检出」的守卫钩子(Edit/Write 那个和 Bash 那个)判断「当前是不是 linked worktree」的方法换掉:原来看路径里有没有 为什么改:守卫的唯一职责是拦住往共享主检出里写。原判据在最常见的布局下失效:仓库放在名为 风险与代价(含回滚):方向是收紧(以前放行的现在拦),理论代价是误拦;本仓四个矩阵 326 用例全绿(两仓合计 379),含子模块、真 linked worktree、非仓库目录三类正向场景。关键坑已实测并写进注释: 席位意见:建议批准,两仓同批。四轴:业务——守卫在最常见布局下失效是实测的;长远——用 git 的结构判据替代路径猜测;防 AI 错——这正是防 agent 误写共享树的那道门;创业阶段——四文件、无新依赖、不扩面。 你要做的:两仓一起批准并人工合并(本 PR 与 objectstack #15924)。一字:是/否。 Generated by Claude Code |
|
Status for the approvers: the objectstack half of this flight, PR #15924, MERGED at 16:03:37Z ( Generated by Claude Code |
Ruling recorded — approved; the maintainer has queued it (director seat, decision batch #72, 2026-09-07)Maintainer reply, verbatim: 「7749 已加入队列,其他同意」. The maintainer approved this PR and added it to the merge queue themselves (timeline: Recorded for the ledger:
Generated by Claude Code |
Why the merge queue evicted this PR twice — a real red, and it needs a patch round before the maintainer re-queues (director seat, 2026-09-07 07:4x UTC)Read from the merge-group run of 06:09Z (
Verdict, verbatim: Mechanism. Patch round (this PR's lane, not the maintainer): re-sync the pin to the objectstack commit that landed the sibling fix (#15924 merged as Generated by Claude Code |
…uctural-linked-worktree-test
…upstream The merge queue failed twice on `check-upstream-port-parity` while PR-side CI was green: `.claude/hooks/guard-main-checkout.selftest.sh` is a PINNED port of objectstack's copy, and deleting the KNOWN HOLE block that five declared divergences describe left each of them matching zero times. Bumping the pin IS the re-sync, so this runs the gate's own procedure against objectstack@70e77ec3b rather than touching a digest by hand. Upstream has since landed both halves of this work itself — the structural git-dir vs git-common-dir section (objectstack#15924) and the routed-path-key table this branch already carried — so the declared divergence set collapses from 14 entries to 2: ported-from-provenance-header the objectui-only provenance paragraph worktrees-section-crossref-repointed #11809 -> #7259, this repo's own card Everything the other twelve described is now upstream text, which is the direction the pin's header says the set is meant to move. The re-sync also brings in objectstack#15987, which this branch was behind: the escape hatch now names the environment it actually works in instead of printing a `VAR=1` command prefix that cannot reach a hook. That section asserts the hook's refusal text, so `guard-main-checkout.sh` carries the same wording at both of its message sites — otherwise the ported rows would fail. With that in, the two repos' hooks differ by 10 diff lines, all inside comment blocks, and no executable line differs, exactly as the provenance header claims. guard-main-checkout.selftest.sh: 116 -> 120 passed, 0 failed (the four new #15987 rows). The other three hook self-tests are unmoved at 126 / 48 / 36. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH
Patch round: the merge-queue gate is greenPushed head: Red first, on the merged head, before anything changed
What the re-sync changedPin ref exit 0. Declared divergences for this file: 14 → 2. Upstream has since absorbed twelve of them, which is the direction the pin's own header says the set is meant to move.
Dissolved, because upstream now carries the same text:
Which hunks came from which upstream PRobjectstack#15924 ( objectstack#15987 (
Point 2 is not optional decoration: the ported rows assert the hook's refusal text ( That also repairs the provenance header's own measurement. The two repos' Gate verdicts — exit codes captured before any pipeAll at
Hook self-tests, all exit 0:
The +4 is exactly the #15987 section's four rows. The other three matrices are unmoved. Changeset — verified with the script, not assumed: Whole-repo lint is a real full run, not a narrowing. For the record all three changed files are outside eslint's population — AblationFrom the committed state, reverting the pin bump alone (
So the pin bump is load-bearing, and the restore is proven by hash rather than by a clean exit code. Two things for the maintainer, neither fixed here1. The pin's Both statements are false as to the ref. Measured:
They stay green — the digest comparison is the assertion and their bytes are untouched — but the ref beside them now names a tree their digests were not taken from. I did not resync them, and I could not have made the ref fully honest in this round either way. Probing both with the gate's own
Repairing the printed provenance properly means a per-file 2. The Bash sibling still prints the remedy #15987 removed. PR state — untouched, and one thing I did not do
Re-queue at your convenience — the gate that failed twice is green at Generated by Claude Code |
Patch round landed on the branch — head
|
|
Correction to the comment above: the two follow-up findings are #8287 (Bash guard's dead Generated by Claude Code |
…global one The pin carried ONE global `upstream.ref` beside PER-FILE digests, and `--resync` set that global field on every run while updating only the re-synced entry's digest. Re-syncing one file therefore re-labelled the others with a ref their digests had never been taken from. Measured on the tree: after #7749 re-synced the hook self-test to objectstack `70e77ec3b`, the gate printed that ref beside all three files. The other two carry digests taken at `bf10debd5`, confirmed by hashing the upstream blobs at both refs: scripts/pm/check-half-states.mjs bf10debd5 449a0aec… 70e77ec3b 274bac47… scripts/invoked-as.mjs bf10debd5 90f72bf4… 70e77ec3b 6d99f65c… The pinned digests are the `bf10debd5` ones. All three stayed GREEN — the digest is the assertion and the bytes were untouched — while the provenance line beside two of them was false. `ref` is now a required field on each `files[]` entry and the global one is RETIRED rather than kept as a default: a default would be read as `entry.ref ?? pin.upstream.ref`, which is the same false label re-spelled as a feature. `validatePin` refuses a pin that still carries `upstream.ref`. `upstream.repo` stays global — it is measurably uniform. The two entries' refs are corrected to `bf10debd5`. That is a correction of a false label, NOT a re-sync: no digest, no divergence and no ported file's bytes change (the diff on the pin is one removed global ref and three added per-file refs, nothing else). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
Fixes #7259
Sibling PR (objectstack, same defect, same lines, one flight): objectstack-ai/objectstack#15924
Sibling card: objectstack-ai/objectstack#11809
What was wrong
Both worktree-first guards decided "am I in a linked worktree?" by substring-matching the git-dir path:
That is a test for the characters
worktreesappearing anywhere in a path. A primary checkout that merely lives under a directory namedworktreesmatched it, and both guards allowed edits into a shared primary checkout — the exact failure worktree-first exists to stop.It was also depth-dependent, which is what made it reachable in practice.
git rev-parse --git-dirprints a RELATIVE.gitat a repo toplevel and an ABSOLUTE path from any subdirectory, and the guards hand git the edited file's nearest EXISTING ancestor. Measured on the fixture, unchanged from the card:ODD/README.mdODD/brand/new/f.tsODD/pkg/x.tsODD/pkg/brand/new/f.tsIn a real repo almost every edit is to a file in a subdirectory that already exists. Note the fixture path is not exotic: an operator who keeps trees under a
worktrees/parent directory gets a silently unguarded primary checkout.The fix — structural, not a sharper pattern
A linked worktree's git-dir (
.git/worktrees/NAME) differs from its git-common-dir (.git). A primary checkout has the two equal, and so does a submodule (.git/modules/NAMEfor both) — which is why neither needs a special case. This holds regardless of how the path is spelled.Two details are load-bearing, and both were measured on this box (git 2.43.0) rather than assumed:
--git-common-dirprints RELATIVE to the directory queried —.gitat a toplevel,../.gitfrom a subdirectory. It must be resolved against that directory before the comparison. Ablating only that one line turns 47 matrix cases fromblocktoallow: compared raw it never equals the absolute git-dir, so the guard fails open at every depth.--absolute-git-diralone is NOT a fix, exactly as the sibling card's second comment warns. It removes the toplevel/subdirectory asymmetry by making the guard fail open everywhere instead of somewhere. Measurement (1) is the direct evidence for that.Both sides go through one canonicalisation helper, so symlinked temp dirs and git's relative printing cannot make two spellings of the same directory look different.
A failed
rev-parsekeeps today's behaviour — not being inside a repo is not this hook's business.Red / green
The self-test edits landed first, so the flip is shown in both directions. The old hooks were taken from
HEADinto a scratch directory; the checked-in tree was never mutated.New matrices against the OLD hooks:
Only the subdirectory cases redden — the toplevel ones passed against the old hook too, because they blocked by accident. That is the sibling card's sharpened trigger condition reproducing exactly.
New matrices against the NEW hooks, and every other hook matrix in this repo:
The Bash matrix goes 121 to 126 (the
worktrees-segment fixture it never had). The Edit/Write matrix stays at 116 — four cases flipped rather than added.Hook Self-Testsdiscovers all four byfind, so nothing here needed a workflow edit.Non-vacuity. The recipe printed in the matrix footer was re-aimed at the line that now exists, and then run, so it is not a dead mutation: deleting the linked-worktree escape reddens 29 cases. The deletion was confirmed on disk by a before/after grep count (1 to 0) before the reading was trusted.
Self-test changes
KNOWN HOLEbanner and its "record of today's behaviour" prose are gone. The fourworktrees-segment cases stay, re-homed under a section titled for the primary checkout they describe, and all four now expectblock.$WTis a real linked worktree made withgit worktree add, at a path carrying noworktreessegment, allowed at both its toplevel ($WT/README.md) and in a subdirectory ($WT/pkg/x.ts). Both depths matter because git answers them differently. No new case was needed — a comment now says why those rows are load-bearing.ODDfixture and five cases: three blocks into it (subdirectory viased -i, subdirectory via redirection, toplevel) plus the primary-checkout control and a linked-worktree control beside them.Cross-repo alignment
Both repos move together in one flight, and the port stays verbatim. After the change, measured with
diff -u:guard-main-checkout.sh— 10 changed diff lines, all inside comment blocks (the pre-existing worktree-recipe block, plus the one line carrying each repo's own card number). Stripping comments and blank lines, the 41 code lines are identical.guard-main-checkout-bash.sh— 82 changed diff lines: 76 comments, and 6 lines of message prose inside the blocked-message heredoc (each repo's own issue reference and its reflow). Stripping comments, blank lines and heredoc bodies, the 342 code lines are identical.So the residual is comment-and-message-prose only; no executable line differs between the two repos in either hook — which is what this matrix's own header demands.
Gates
Derived from this repo's
AGENTS.md,package.jsonand.github/workflows/for a.claude/hooks/**change. All at HEAD2248bba, exit codes captured before any pipe:node scripts/check-changeset-presence.mjspnpm check:control-bytespnpm check:shell-escape-residuepnpm check:governed-queue-guard(self-test)pnpm lint(whole repo,turbo run lint, 47/47 tasks).claude/hooks/*.selftest.sh(whatHook Self-Testsruns)node scripts/check-governed-queue-guard.mjs --teston the four pathsChangeset. This repo has no
skip-changesetlabel and none was invented or applied. The authority isscripts/check-changeset-presence.mjs, and its verdict line on this diff is:So no
.changeset/*.mdis added — not by exemption, but because nothing guarded changed. Whole-repo lint is reported as a full run rather than a narrowing; for the record its scan surface is**/*.{ts,tsx}, and this diff contains no TypeScript, so the 2883 warnings it reports are pre-existing and untouched.Maintainer notes
This PR is governed surface (
.claude/**) and is parked as a draft: no seat flips it ready, enqueues it, or arms auto-merge. Attribution for this change: authored in Claude Code sessionsession_019RfFHiRCSs3JXLK4cwcfox.维护者速读(草稿)
改了什么 —— 两个 worktree-first 守卫判断"我是不是在 linked worktree 里"的方式换掉了。原来靠路径里有没有
worktrees这几个字符,现在问 git 一个结构性问题:git-dir 和 git-common-dir 是不是同一个目录,不同才是 linked worktree。四个文件,两个钩子加它们各自的自测矩阵;objectstack 同步落一份,可执行行逐字节相同。为什么改 —— 这个守卫存在的唯一理由,就是拦住"往共享主 checkout 里写"。而它恰好在最常见的情形下失灵:只要你的仓库放在一个叫
worktrees的目录底下(比如~/worktrees/objectui这种再普通不过的布局),编辑任何子目录里的文件都会被放行 —— 而真实开发里几乎每一次编辑都是子目录里的已有文件。失灵时没有任何报错,agent 的改动就这么写进共享树,下一个 agent 切 HEAD 时静默清掉。这不是理论风险,是守卫在它最该起作用的那一类输入上直接失效。风险与代价(含回滚) —— 改动面很小:两个钩子里各一段判定,没有新依赖,
git rev-parse --git-common-dir是老接口(本机实测 git 2.43.0)。收紧方向是"以前放行的现在拦住",所以理论上的代价是误拦 —— 但本仓四个矩阵共 326 个用例全绿(加 objectstack 那边共 379),含子模块、真 linked worktree、非仓库目录三类正向场景,没有一条正常路径被误伤。真正的坑我们提前量过并写进了代码注释:--git-common-dir打印的是相对路径,不先解析就比较会让守卫在所有深度上失效(实测 47 条用例翻车),所以卡片上"光换--absolute-git-dir就行"的说法是错的,这版没有采纳。回滚成本接近零:revert 这一个 commit 即可,守卫回到今天的行为,没有数据迁移、没有配置、没有下游消费者。本仓不欠 changeset —— 不是走豁免,是check-changeset-presence判定这次没动任何发版包的源码。席位意见 ——
你要做的 —— 这是 governed surface(
.claude/**),按规矩只能你本人合。请看一眼两个仓的 PR(这一个和 objectstack 的 https://github.com/objectstack-ai/objectstack/pull/15924),确认判定换法你认可,然后手动合并;两个仓要一起合,否则两边守卫会短暂不一致。席位不会翻 ready、不会入队、不会开自动合并。🤖 Generated with Claude Code
Generated by Claude Code
Generated by Claude Code